Gå ud over traditionelle eksempelbaserede tests. Denne omfattende guide udforsker egenskabsbaseret testning i JavaScript med fast-check, som hjælper dig med at finde flere fejl med mindre kode.
Ud over eksempler: Et dybdegående kig på egenskabsbaseret testning i JavaScript
Som softwareudviklere bruger vi en betydelig mængde tid på at skrive tests. Vi udarbejder omhyggeligt unit-tests, integrationstests og end-to-end-tests for at sikre, at vores applikationer er robuste, pålidelige og fri for regressioner. Det dominerende paradigme for dette er eksempelbaseret testning. Vi tænker på et specifikt input, og vi hævder et specifikt output. Input `[1, 2, 3]` bør producere output `6`. Input `"hello"` bør blive til `"HELLO"`. Men denne tilgang har en tavs, lurende svaghed: vores egen fantasi.
Hvad nu hvis du glemmer at teste med et tomt array? Et negativt tal? En streng, der indeholder Unicode-tegn? Et dybt indlejret objekt? Hvert overset edge case er en potentiel fejl, der venter på at ske. Det er her, Egenskabsbaseret Testning (PBT) kommer ind i billedet og tilbyder et kraftfuldt paradigmeskift, der hjælper os med at bygge mere selvsikker og robust software.
Denne omfattende guide vil føre dig gennem verdenen af egenskabsbaseret testning i JavaScript. Vi vil udforske, hvad det er, hvorfor det er så effektivt, og hvordan du kan implementere det i dine projekter i dag ved hjælp af det populære bibliotek `fast-check`.
Begrænsningerne ved traditionel eksempelbaseret testning
Lad os betragte en simpel funktion, der sorterer et array af tal. Ved hjælp af et populært framework som Jest eller Vitest kunne vores test se sådan ud:
// En simpel (og lidt naiv) sorteringsfunktion
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// En typisk eksempelbaseret test
test('sortNumbers skal sortere et simpelt array korrekt', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Denne test består. Vi kunne tilføje et par `it`- eller `test`-blokke mere:
- Et array, der allerede er sorteret.
- Et array med negative tal.
- Et array med et nul.
- Et tomt array.
- Et array med duplikerede tal (hvilket vi allerede har dækket).
Vi har det godt. Vi har dækket det grundlæggende. Men hvad har vi overset? Hvad med `[-0, 0]`? Hvad med `[Infinity, -Infinity]`? Hvad med et meget stort array, der kan ramme performancegrænser eller mærkelige JavaScript-motoroptimeringer? Det grundlæggende problem er, at vi manuelt udvælger dataene. Vores tests er kun så gode som de eksempler, vi kan forestille os, og mennesker er notorisk dårlige til at forestille sig alle de mærkelige og vidunderlige måder, data kan struktureres på.
Eksempelbaseret testning validerer, at din kode virker for nogle få håndplukkede scenarier. Egenskabsbaseret testning validerer, at din kode virker for hele klasser af input.
Hvad er egenskabsbaseret testning? Et paradigmeskift
Egenskabsbaseret testning vender tingene på hovedet. I stedet for at hævde, at et specifikt input giver et specifikt output, definerer du en generel egenskab ved din kode, som skal være sand for ethvert gyldigt input. Testframeworket genererer derefter hundreder eller tusinder af tilfældige input for at forsøge at modbevise din egenskab.
En "egenskab" er en invariant – en overordnet regel om din funktions adfærd. For vores `sortNumbers`-funktion kunne nogle egenskaber være:
- Idempotens: At sortere et allerede sorteret array bør ikke ændre det. `sortNumbers(sortNumbers(arr))` skal være det samme som `sortNumbers(arr)`.
- Længdeinvarians: Det sorterede array skal have samme længde som det oprindelige array.
- Indholdsinvarians: Det sorterede array skal indeholde nøjagtigt de samme elementer som det oprindelige array, blot i en anden rækkefølge.
- Rækkefølge: For ethvert par af tilstødende elementer i det sorterede array, `sorted[i] <= sorted[i+1]`.
Denne tilgang flytter dig fra at tænke på individuelle eksempler til at tænke på den grundlæggende kontrakt for din kode. Dette skift i tankegang er utroligt værdifuldt for at designe bedre og mere forudsigelige API'er.
Kernekomponenterne i PBT
Et framework for egenskabsbaseret testning har typisk to nøglekomponenter:
- Generatorer (eller Arbitraries): Disse er ansvarlige for at producere et bredt udvalg af tilfældige data i henhold til specificerede typer (heltal, strenge, arrays af objekter osv.). De er smarte nok til ikke kun at generere "happy path"-data, men også vanskelige edge cases som tomme strenge, `NaN`, `Infinity` og mere.
- Shrinking: Dette er den magiske ingrediens. Når frameworket finder et input, der falsificerer din egenskab (dvs. forårsager en testfejl), rapporterer det ikke bare det store, tilfældige input. I stedet forsøger det systematisk at finde det mindste og enkleste input, der stadig forårsager fejlen. Dette gør fejlfinding eksponentielt lettere.
Kom godt i gang: Implementering af PBT med `fast-check`
Selvom der findes flere PBT-biblioteker i JavaScript-økosystemet, er `fast-check` et modent, kraftfuldt og velvedligeholdt valg. Det integreres problemfrit med populære testframeworks som Jest, Vitest, Mocha og Jasmine.
Installation og opsætning
Først skal du tilføje `fast-check` til dit projekts udviklingsafhængigheder. Vi antager, at du bruger en test-runner som Jest.
npm install --save-dev fast-check jest
# eller
yarn add --dev fast-check jest
# eller
pnpm add -D fast-check jest
Din første egenskabsbaserede test
Lad os omskrive vores `sortNumbers`-test ved hjælp af `fast-check`. Vi vil teste "rækkefølge"-egenskaben, som vi definerede tidligere: hvert element skal være mindre end eller lig med det, der følger efter.
import * as fc from 'fast-check';
// Den samme funktion som før
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('output fra sortNumbers skal være et sorteret array', () => {
// 1. Beskriv egenskaben
fc.assert(
// 2. Definer arbitraries (input-generatorer)
fc.property(fc.array(fc.integer()), (data) => {
// `data` er et tilfældigt genereret array af heltal
const sorted = sortNumbers(data);
// 3. Definer prædikatet (egenskaben der skal tjekkes)
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // Egenskaben er falsificeret
}
}
return true; // Egenskaben holder for dette input
})
);
});
test('sortering bør ikke ændre arrayets længde', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
Lad os bryde det ned:
- `fc.assert()`: Dette er køreren. Den vil udføre din egenskabstjek mange gange (100 som standard).
- `fc.property()`: Dette definerer selve egenskaben. Den tager en eller flere arbitraries som argumenter, efterfulgt af en prædikatfunktion.
- `fc.array(fc.integer())`: Dette er vores arbitrary. Den fortæller `fast-check`, at den skal generere et array (`fc.array`) af heltal (`fc.integer()`). `fast-check` vil automatisk generere arrays af forskellige længder, med forskellige heltalsværdier (positive, negative, nul osv.).
- Prædikatet: Den anonyme funktion `(data) => { ... }` er, hvor vores logik bor. Den modtager de tilfældigt genererede data og skal returnere `true`, hvis egenskaben holder, eller `false`, hvis den overtrædes. `fast-check` understøtter også prædikatfunktioner, der kaster en fejl ved fiasko, hvilket integreres pænt med Jests `expect`-assertions.
Nu, i stedet for én test med ét håndplukket array, har vi en test, der verificerer vores sorteringslogik mod 100 forskellige, automatisk genererede arrays, hver gang vi kører vores test suite. Vi har massivt øget vores testdækning med blot et par linjers kode.
Udforskning af Arbitraries: Generering af de rigtige data
Styrken ved PBT ligger i dens evne til at generere forskelligartede og udfordrende data. `fast-check` tilbyder et rigt sæt af arbitraries til at dække næsten enhver datastruktur, du kan forestille dig.
Grundlæggende Arbitraries
Disse er byggeklodserne for din datagenerering.
- `fc.integer()`, `fc.float()`, `fc.bigInt()`: For tal. De kan begrænses, f.eks. `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()`: For strenge med forskellige tegnsæt.
- `fc.boolean()`: For `true` eller `false`.
- `fc.constant(value)`: Returnerer altid den samme værdi. Nyttig til at blande med `fc.oneof`.
- `fc.constantFrom(val1, val2, ...)`: Returnerer en af de angivne konstante værdier.
Komplekse og sammensatte Arbitraries
Du kan kombinere grundlæggende arbitraries for at skabe komplekse datastrukturer.
- `fc.array(arbitrary, constraints)`: Genererer et array af elementer skabt af den angivne arbitrary. Du kan begrænse `minLength` og `maxLength`.
- `fc.tuple(arb1, arb2, ...)`: Genererer et array med fast længde, hvor hvert element har en specifik, forskellig type.
- `fc.object(shape)`: Genererer objekter med en defineret struktur. Eksempel: `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)`: Genererer en værdi fra en hvilken som helst af de angivne arbitraries. Dette er fremragende til at teste funktioner, der håndterer flere datatyper (f.eks. `string | number`).
- `fc.record({ key: arb, value: arb })`: Genererer objekter, der skal bruges som ordbøger eller maps, hvor nøgler og værdier genereres fra arbitraries.
Oprettelse af brugerdefinerede Arbitraries med `map` og `chain`
Nogle gange har du brug for data, der ikke passer ind i en standardform. `fast-check` giver dig mulighed for at oprette dine egne arbitraries ved at transformere eksisterende.
Brug af `.map()`
`.map()`-metoden transformerer outputtet fra en arbitrary til noget andet. Lad os for eksempel oprette en arbitrary, der genererer ikke-tomme strenge.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// Eller, ved at transformere et array af tegn
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
Brug af `.chain()`
`.chain()`-metoden er mere kraftfuld. Den giver dig mulighed for at oprette en ny arbitrary baseret på den genererede værdi fra en tidligere. Dette er afgørende for at skabe korrelerede data.
Forestil dig, at du skal generere et array og derefter et gyldigt indeks for det samme array. Du kan ikke gøre dette med to separate arbitraries, da indekset kan være uden for grænserne. `.chain()` løser dette perfekt.
// Generer et array og et gyldigt indeks til det
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Baseret på det genererede array `arr`, opret en ny arbitrary for indekset
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Returner en tuple af arrayet og det genererede indeks
return fc.tuple(fc.constant(arr), indexArb);
});
// Anvendelse i en test
test('at slice ved et gyldigt indeks bør virke', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// Både `arr` og `index` er garanteret at være kompatible
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
Kraften i Shrinking: Fejlfinding gjort let
Den absolut mest overbevisende funktion ved egenskabsbaseret testning er shrinking. For at se det i aktion, lad os skabe en bevidst fejlbehæftet funktion.
// Denne funktion fejler, hvis input-arrayet indeholder tallet 42
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('Dette tal er ikke tilladt!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug skal summere tal', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Når du kører denne test, vil `fast-check` næsten helt sikkert finde et tilfælde, der fejler. Men den vil ikke rapportere det første tilfældige array, den fandt, som måske er noget i stil med `[-1024, 500, 42, 987, -2000]`. En sådan fejlrapport er ikke særlig hjælpsom. Du ville skulle inspicere den manuelt for at finde den problematiske `42`.
I stedet vil `fast-check`'s shrinker træde i kraft. Den vil se fejlen og begynde at forenkle inputtet:
- Kan jeg fjerne et element? Prøv `[500, 42, 987, -2000]`. Fejler stadig. Godt.
- Kan jeg fjerne et mere? Prøv `[42, 987, -2000]`. Fejler stadig.
- ...og så videre, indtil den ikke kan fjerne flere elementer, uden at testen består.
- Den vil også forsøge at gøre tallene mindre. Kan `42` være `0`? Nej, testen består. Kan det være `41`? Testen består. Den indsnævrer det.
Den endelige fejlrapport vil se nogenlunde sådan ud:
Error: Property failed after 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Counterexample: [[42]]
Shrunk 5 time(s)
Got error: Dette tal er ikke tilladt!
Den fortæller dig det præcise, minimale input, der forårsagede fejlen: et array, der kun indeholder tallet `[42]`. Dette peger dig straks i retning af fejlens kilde og sparer dig for enorm tid og besvær med fejlfinding.
Praktiske PBT-strategier og eksempler fra den virkelige verden
PBT er ikke kun for matematiske funktioner. Det er et alsidigt værktøj, der kan anvendes på mange områder af softwareudvikling.
Egenskab: Inverse funktioner
Hvis du har en funktion, der koder data, og en anden, der afkoder dem, er de hinandens inverse. En fremragende egenskab at teste er, at afkodning af en kodet værdi altid skal returnere den oprindelige værdi.
// `encode` og `decode` kunne være for base64, URI-komponenter eller brugerdefineret serialisering
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) skal være lig med x', () => {
// `fc.jsonValue()` genererer enhver gyldig JSON-værdi: strenge, tal, objekter, arrays
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Egenskab: Idempotens
En operation er idempotent, hvis anvendelse af den flere gange har samme effekt som at anvende den én gang. `f(f(x)) === f(x)`. Dette er en afgørende egenskab for ting som datarensningsfunktioner eller `DELETE`-endepunkter i en REST API.
// En funktion, der fjerner foranstående/efterfølgende mellemrum og kollapser flere mellemrum
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace skal være idempotent', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Egenskab: Tilstandsbaseret (modelbaseret) testning
Dette er en mere avanceret, men utroligt kraftfuld teknik til at teste systemer med intern tilstand, som f.eks. en UI-komponent, en indkøbskurv eller en tilstandsmaskine. Ideen er at skabe en simpel softwaremodel af dit system og en række kommandoer, der kan køres mod både din model og den virkelige implementering. Egenskaben er, at tilstanden af modellen og tilstanden af det virkelige system altid skal matche.
`fast-check` tilbyder `fc.commands` til dette formål. Lad os modellere en simpel tæller:
// Den rigtige implementering
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// Kommandoerne for fast-check
const incrementCmd = fc.command(
// check: en funktion til at tjekke, om kommandoen kan køres på modellen
(model) => true,
// run: en funktion til at udføre kommandoen på både model og det virkelige system
(model, real) => {
model.count++;
real.increment();
expect(real.get()).toBe(model.count);
}
);
const decrementCmd = fc.command(
(model) => true,
(model, real) => {
model.count--;
real.decrement();
expect(real.get()).toBe(model.count);
}
);
test('Counter skal opføre sig i overensstemmelse med modellen', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
I denne test vil `fast-check` generere en tilfældig sekvens af `increment`- og `decrement`-kommandoer, køre dem mod både vores simple objektmodel og den rigtige `Counter`-klasse, og sikre, at de aldrig afviger. Dette kan afdække subtile fejl i kompleks tilstandslogik, som ville være næsten umulige at finde med eksempelbaseret testning.
Hvornår man IKKE skal bruge egenskabsbaseret testning
PBT er en kraftfuld tilføjelse til din testværktøjskasse, men det er ikke en erstatning for alle andre former for testning. Det er ikke en mirakelkur.
Eksempelbaseret testning er ofte bedre, når:
- Man tester specifikke, kendte forretningsregler. Hvis en skatteberegning skal producere præcis `10,53 kr.` for et specifikt input, er en simpel eksempelbaseret test klarere og mere direkte. Dette er en regressionstest for et kendt krav.
- "Egenskaben" blot er "input X producerer output Y". Hvis der ikke er nogen overordnet, generaliserbar regel om funktionens adfærd, kan det være mere komplekst end det er værd at tvinge en egenskabsbaseret test igennem.
- Man tester brugergrænseflader for visuel korrekthed. Selvom du kan teste tilstandslogikken i en UI-komponent med PBT, er det bedre at tjekke for et specifikt visuelt layout eller stil med snapshot-testning eller visuelle regressionsværktøjer.
Den mest effektive strategi er en hybrid tilgang. Brug egenskabsbaserede tests til at stressteste dine algoritmer, datatransformationer og tilstandslogik mod et univers af muligheder. Brug traditionelle eksempelbaserede tests til at fastlægge specifikke, kritiske forretningskrav og forhindre regressioner på kendte fejl.
Konklusion: Tænk i egenskaber, ikke kun eksempler
Egenskabsbaseret testning opfordrer til et dybtgående skift i, hvordan vi tænker om korrekthed. Det tvinger os til at træde et skridt tilbage fra individuelle eksempler og overveje de grundlæggende principper og kontrakter, vores kode skal overholde. Ved at gøre det kan vi:
- Afdække overraskende edge cases, som vi aldrig ville have tænkt på at skrive tests for.
- Opnå meget større tillid til robustheden af vores kode.
- Skrive mere udtryksfulde tests, der dokumenterer adfærden af vores system i stedet for blot dets output på nogle få input.
- Drastisk reducere fejlfindingstid takket være kraften i shrinking.
At tage egenskabsbaseret testning til sig kan føles uvant i starten, men investeringen er det hele værd. Start i det små. Vælg en ren funktion i din kodebase – en der håndterer datatransformation eller en kompleks beregning – og prøv at definere en egenskab for den. Tilføj én egenskabsbaseret test til dit næste projekt. Når du ser den finde sin første ikke-trivielle fejl, vil du være overbevist om dens evne til at bygge bedre og mere pålidelig software for et globalt publikum.
Yderligere ressourcer
- fast-check officiel dokumentation
- Forståelse af egenskabsbaseret testning af Scott Wlaschin (en klassisk, sproguafhængig introduktion)